Skip to content

feat: parallel prefix scan for UNBOUNDED PRECEDING window aggregates - #2211

Draft
avantgardnerio wants to merge 5 commits into
apache:mainfrom
avantgardnerio:brent/prefix-merge-scaffold
Draft

feat: parallel prefix scan for UNBOUNDED PRECEDING window aggregates#2211
avantgardnerio wants to merge 5 commits into
apache:mainfrom
avantgardnerio:brent/prefix-merge-scaffold

Conversation

@avantgardnerio

@avantgardnerio avantgardnerio commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

BoundedWindowAggExec declares SinglePartition when there is no PARTITION BY, because a window frame spans rows across the whole input. For an ever-expanding frame that means the entire window computation runs on one core, however wide the cluster.

Measured on h2o window.sql Q7 at 1e7, 8 partitions, 2 executors:

Stage Work elapsed_compute
0 sort, 8 way parallel 1.94s
1 SortPreservingMergeExec collapses 8 to 1, then the window 9.75s

This PR removes the collapse. Each task computes a partition-local running aggregate over a range-disjoint slice, and a downstream operator corrects it with the merged accumulator state of every prior partition. A halo cannot cover this case, since an UNBOUNDED PRECEDING frame reaches back to the first row of the dataset.

Shape

PrefixMergeExec                        state applied row-wise
  ExchangeExec (partitioning: None)    boundary 2, planted by the rule
    PartitionedBoundedWindowAggExec    window runs per partition
      RangeFilterExec (halo 0/0)       trim to this task's cut range
        ExchangeExec                   boundary 1, inserted by DistributedExchangeRule
          RuntimeStatsExec
            OrderedRangeRepartitionExec
              SortExec
                RuntimeStatsExec
                  <source>

Boundary 2 carries no repartition. It exists so the scheduler has a point where every upstream task has published its state.

State flow

DataFusion 55 exposes accumulator state through a callback (apache/datafusion#24035). A WindowStateCollector catches it, ShuffleWriterExec translates each capture's task-local partition index to a stage-global one, and it rides SuccessfulTask to the scheduler. The scheduler prefix-merges the reports into one carry-in per partition and binds them to the downstream PrefixMergeExec.

Merging goes through the aggregate's own merge_batch, so non-decomposable aggregates work: two approx_distinct HLL sketches combine correctly where two distinct counts could not.

Correctness

ballista/client/tests/prefix_window.rs runs a distributed running sum against an independently computed oracle through the real scheduler, shuffle and executor, with max_partitions_per_task = 2 so tasks carry genuine multi-partition slices.

Safety

Gated behind ballista.planner.parallel_window.enabled, off by default. The rewrite fires only on: no PARTITION BY, a single ascending ORDER BY column, Float64 routing, and an ever-expanding frame. Everything else falls through untouched, including the existing halo rewrite, whose sliding frames DataFusion refuses to publish state for.

Known limits

  • Float64 routing only. OrderedRangeRepartitionExec routes on a T-Digest, which is Float64-only until the KLL migration. h2o Q7 orders by id3 (Int64) and so cannot take this path yet — the blocker is the sketch, not this rule.
  • No PARTITION BY, single ORDER BY column, ASC only.
  • WindowApply::Scalar is implemented but not yet selected; every apply currently routes through the accumulator path.

Still draft

Benchmarks are not in yet, and the scalar-path selection lands before merge. Review threads on the earlier feedback are open inline.

@andygrove

Copy link
Copy Markdown
Member

CI is red on two jobs here, but only one of them is yours.

cargo doc is real:

error: public documentation for `FinalizedPartitionState` links to private item `self`
error: public documentation for `PrefixMergeExec` links to private item `self`

The [module-level docs][self] links resolve to mod prefix_merge, which is private, so rustdoc rejects them under -D warnings. Making it pub mod prefix_merge; in execution_plans/mod.rs is the smallest fix and matches what plan_algebra and sort_shuffle already do. Dropping the two intra doc links works too if you'd rather keep the module private.

test linux crates is not this PR. It's ballista-chaos::ha exhausted_retries_fail_the_job_and_leave_the_cluster_healthy::case_1_aqe_off failing at cluster startup with executor registration ConnectionRefused, so an infrastructure flake. Should clear on a rerun.

Design feedback coming in a separate comment.

@andygrove

Copy link
Copy Markdown
Member

Design feedback, separate from the CI note above. The overall shape reads well to me, and the module header is genuinely good documentation. Splitting the global prefix merge onto the scheduler and leaving a row wise apply on the executor is the right decomposition, and the APPROX_DISTINCT case makes a convincing argument for why the Aggregate path has to exist. A few things I'd want settled before this grows more code on top of it.

The per row accumulator replay looks like a performance trap. AggregateApply::apply calls update_batch on a one row slice and then evaluate() once per row. For SUM that's just wasteful, but the motivating cases are sketches, and that's where it gets expensive. evaluate() on an HLL scans every register to produce a cardinality estimate, and on TDigest or KLL it runs a quantile computation. Doing that once per row turns a linear pass into something quite a bit worse, and it re derives work the upstream BWAG already did. The APPROX_DISTINCT test proves correctness on three rows, which is exactly the size that won't surface this. Could you run it over a realistic partition before we commit to the shape? If the numbers are bad there may be a middle path where the upstream emits partial state columns and the correction stays batch at a time.

Serde is deferred, and it's the hard part. #2255 landed its proto message and codec arm in the same PR, and I'd like this one to end up there too, since PrefixMergeExec can't reach an executor without them. No objection to a scaffold that defers it, I just want to flag that the remaining work isn't mechanical. Arc<AggregateUDF>, Vec<Arc<dyn PhysicalExpr>>, and Vec<ScalarValue> sketch state all have to cross the wire.

The related question is the transport the design picks. The description says upstream state reaches the scheduler over task status. That's a hot and frequent message, and HLL, KLL or TDigest state per task per window expression is not small. Since you describe the division of labor as fixed at design time, I'd rather pressure test that choice now than after the scheduler side is built on top of it.

Partition index coupling has no guard. Both per_partition_state[k] and Scalar.offset[k] are keyed by partition index, but the operator declares UnspecifiedDistribution, no required input ordering, and maintains_input_order: true, and with_new_children only re validates counts. So any rule that repartitions the input to the same partition count would silently attach each partition's offsets to the wrong rows. Wrong answers, no error. In practice the scheduler hands over a finished plan so it may never happen, but this is the "correct on one node, silently wrong once split across stages" shape that user-personas.md calls out for Persona 1, and I'd want at least a loud invariant comment on it.

Smaller things:

  • ScalarOp::Overwrite is documented as fitting first_value and last_value. first_value I follow. For the cumulative frame last_value is just the current row's value and needs no correction at all, so overwriting every row with a single scalar would be wrong. Which frame is that aimed at?
  • No metrics. PrefixMergeExec doesn't implement metrics() and ApplyStream has no BaselineMetrics. Given the first point above this is the operator you'd most want timings from, and Spark shaped users lean on per operator timings for skew debugging.
  • Type drift is only caught by accident. If numeric::add promotes, or evaluate() returns a different type than the column it replaces, RecordBatch::try_new fails with an opaque arrow error rather than something naming the offending applies[i].
  • ScalarOp and WindowApply are public enums that you say will grow. Marking both #[non_exhaustive] now costs nothing and saves a breaking change on the first new variant. Similarly, FinalizedPartitionState as a transparent pub type alias means swapping it for the real DataFusion type once feat(physical-plan): expose finalized Accumulator state on BoundedWindowAggExec datafusion#24007 lands is a silent public API change. A newtype now would keep that swap internal.
  • Minor housekeeping, the PR description has the "Generated with Claude Code" footer, and CLAUDE.md in the repo asks us to keep that out of PRs.

@avantgardnerio
avantgardnerio force-pushed the brent/prefix-merge-scaffold branch 2 times, most recently from 906eacf to 5a5f695 Compare August 14, 2026 12:01
…e state merge

Introduces PrefixMergeExec as the downstream half of the AQE range-shuffle
prefix-scan pipeline: it takes per-input-partition window-aggregate state that
the scheduler has already prefix-merged and applies it row-wise to the current
partition's output, so cross-partition running aggregates come out correct.

Both apply paths are implemented:

- WindowApply::Aggregate builds a fresh Accumulator per partition, seeds it via
  merge_batch from the offset state, and replays each row through update_batch
  + evaluate to overwrite the output column.
- WindowApply::Scalar applies the ScalarOp batch-at-a-time via arrow kernels:
  numeric::add for Add, cmp::lt_eq/gt_eq + zip for Min/Max, and a constant fill
  for Overwrite.

Purely additive: nothing in-tree constructs a PrefixMergeExec. The remaining
work is the state source — collecting each upstream task's finalized
accumulator state out of BoundedWindowAggExec and transporting it to the
scheduler — which lands separately.

FinalizedPartitionState is defined locally, indexed by window-expression
position, as the shape this operator consumes.

wip(core,scheduler): prefix-window rewrite plants the shape, collector captures state

Follows the data flow end to end for the AQE prefix-scan pipeline. Stages 0
and 1 run on a real cluster; stage 2 is blocked on PrefixMergeExec serde.

PrefixWindowRule: sibling of ParallelWindowRule for UNBOUNDED PRECEDING
frames, gating on start_bound.is_unbounded() where that rule gates on
is_finite() — complementary, so no plan matches both. Plants the ORRE
preamble, a zero-halo RangeFilterExec trim, PBWAG, a passthrough ExchangeExec
for the state round trip, and PrefixMergeExec. Accepts ROWS as well as RANGE
units, which for an unbounded start differ only in tie handling.

Module docs record the rule's actual captured input rather than an assumed
one, including that AQE re-plans and calls optimize three times — hence the
idempotency guard.

WindowStateCollector: implements DataFusion 55's WindowStateObserver and
retains each finalized accumulator state. Retention rather than polling
because Accumulator::state is a destructive read fired at most once per
group. PBWAG installs one exactly when every frame is ever-expanding, the
same condition with_state_observer enforces, so the halo shape is untouched
and the wire format needs no new field.

PartitionSliceable: operators carrying data indexed by global input partition
now implement their own slicing next to the fields being sliced, replacing
two bespoke arms in the scheduler's task builder. RangeFilterExec's bounds and
PrefixMergeExec's state/offsets both slice when a task is restricted to a
partition subset — without which PrefixMergeExec attaches each partition's
offsets to the wrong rows.

Tests: a client-side e2e asserting the running sum against a computed oracle
through the real distributed path. Red until the transport lands, since
PrefixMergeExec is a passthrough with no state. The rule's unit tests pin why
h2o Q7 does not rewrite today — it orders by an Int64 column and ORRE routes
on a Float64-only T-Digest until KLL.

Known gaps: no serde for PrefixMergeExec; no transport from collector to
scheduler; observed partition_idx is task-local and needs pairing with the
task's global partition ids.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

wip(core,executor): ShuffleWriter translates window state to global partition ids

Continues following the data: BWAG accumulator -> collector -> PBWAG getter ->
ShuffleWriter -> executor. Logged at task completion rather than transported,
so the path is exercised end to end before anything is built on it.

The local-to-global translation lives on the writer, not on the operator that
captured the state. A task's plan is restricted to a partition slice, so an
operator mid-plan only ever sees local indices; the writer is the node the
scheduler hands global_output_partition_ids to. Reassembling downstream instead
would have the scheduler re-derive a mapping it already computed, and a prefix
scan fed a permuted order is wrong with nothing to show for it.

Verified on the client e2e: two tasks each covering two partitions previously
both reported local 0 and 1; they now report globals 0/1 and 2/3, with states
10/26/42/58 over input 1..16.

collect_window_state joins collect_plan_metrics and collect_runtime_stats_reports
as a task-completion peer on QueryStageExecutor, wired into both the pull
(execution_loop) and push (executor_server) task paths.

PBWAG grows observed_window_state() and keeps the TODO that the install site
moves when the wrapper collapses. Neither the collector nor the writer-side
walk depends on the wrapper beyond one downcast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

wip(core,scheduler,executor): transport window state to the scheduler

Adds WindowStateReport to SuccessfulTask, completing the path from a task's
BWAG accumulator to the scheduler: collector -> PBWAG -> ShuffleWriter ->
wire -> RunningStage::window_state_reports. Verified on the client e2e, where
four range-disjoint partitions over input 1..16 arrive as globals 0..3 with
states 10/26/42/58.

State and partition key cross as datafusion_common.ScalarValue rather than a
numeric field, so sketch-backed aggregates (approx_distinct's HLL blob) work
unchanged. The proto carries a TODO on payload size: if it stops being small,
write the state as a sidecar beside the shuffle files the way sort-shuffle
already writes <data>.arrow.index, and send only a reference.

Failures fail the task rather than dropping a report. Unlike runtime stats,
which are an optimization input, this state is load-bearing: the downstream
prefix merge is arithmetically wrong without every partition's contribution,
and wrong in a way nothing later detects. Collection is skipped entirely when
execution already failed.

Reports are tagged with their producer task and purged on reset, in both
reset_task_info and reset_tasks. A retried task re-runs its slice and reports
the same global partitions again; without the purge the stage would hold two
states for one partition and the prefix merge would double-count them. The
file-addressing reason RuntimeStats needs its tag does not transfer — the
writer already stamped stage-global ids — but the purge reason does.

Both scheduler task-status paths (classic execution_graph and AQE) and both
executor task paths (pull execution_loop and push executor_server) are wired;
each pair are peer implementations that need every completion hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

wip(core): prefix-scan the accumulated window state on the scheduler

prefix_merge_window_state turns per-partition finalized states into one
carry-in per partition: out[0] empty, out[k] the merge of every partition
before k. That is what a downstream PrefixMergeExec adds to each partition's
local running aggregate to make it global.

Merging goes through the aggregate's own Accumulator::merge_batch rather than
arithmetic here, which is what lets non-decomposable aggregates work — two
approx_distinct HLL sketches combine correctly where two distinct counts
could not. The accumulator comes from PlainAggregateWindowExpr, the type an
ever-expanding frame always produces; a sliding expression reaching this is an
error rather than a silently wrong answer.

Built incrementally, out[k] = merge(out[k-1], state[k-1]), so two merges per
partition rather than merging every prior from scratch. A fresh accumulator
per partition is still required because Accumulator::state is a destructive
read and must not be called twice; seeding it from the previous carry-in is
the same round trip two-phase aggregation makes.

Enforces here what stopped being DataFusion's guarantee when the API turned
out to be push-shaped: a report carrying a PARTITION BY key is rejected,
because FinalizedPartitionState has no key dimension and a second group in one
partition would have nowhere to go.

Tests cover the carry-in arithmetic, independence from report arrival order
(reports arrive in close order and tasks complete in any order), rejection of
a duplicate partition state (only reachable if the producer-task purge failed,
and would double-count), and carrying across an empty partition that closes no
group and so publishes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

wip(core,scheduler): bake the prefix-merged state into PrefixMergeExec

Closes the loop from reports to operator, all in scheduler memory. When a
stage completes, its accumulated window-state reports are prefix-merged and
bound to the PrefixMergeExec waiting on it downstream. Verified on the client
e2e, where four partitions reporting 10/26/42/58 resolve to carry-ins
[None, 10, 36, 78].

State is late-bound, mirroring RangeFilterExec's cuts: try_new_pending for the
rule's plant-time path, try_new_resolved for wire decode and task restriction,
resolve_state as the setter. Both execute() and slice_to_partitions refuse
while unresolved rather than treating an absent carry-in as zero — that would
emit partition-local aggregates, which look plausible and are wrong.

The scheduler hooks update_stage_progress on completion: walk the plan for the
PrefixMergeExec whose state-sync boundary carries this stage id, recover the
window expressions from the operator below that boundary (the exchange retains
its input subtree even once resolved), prefix-merge, resolve. A stage that
reported state with no consumer to bind it to is an error rather than a skip;
the state exists because something downstream cannot be correct without it.

Still passthrough: `applies` is empty, so the operator carries the state
without applying it. The descriptors that turn state into corrected columns,
and the serde that lets the operator reach an executor, are next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

wip(core): serde for PrefixMergeExec

Stage 2 now reaches an executor and runs. The e2e's remaining failure is the
expected one: every partition's running sum is off by exactly its carry-in
(5+10=15, 9+36=45, 13+78=91), so the state that crossed the wire is provably
correct and nothing is applying it yet.

Both WindowApply shapes cross. The aggregate arm carries its UDAF by name,
resolved from the executor's function registry on decode, with args as
PhysicalExprNodes. State crosses as ScalarValue so sketch-backed aggregates
work unchanged, and an absent slot stays distinct from a present-but-empty one
— a non-aggregate window function publishes no state, which is not the same as
publishing nothing.

Encoding refuses while state is unresolved, matching RangeFilterExec's refusal
on unresolved bounds. An executor has no way to obtain prefix state, so a plan
reaching the wire without it could only produce partition-local aggregates.

Round-trip test covers both arms, the UDAF-by-name resolution, and the
None-vs-empty distinction in the state slots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

feat(scheduler,core): apply descriptors — parallel prefix scan is correct

The client e2e passes: a running sum computed across four range-disjoint
partitions matches the serial answer through the real distributed engine,
scheduler and shuffle and executor included.

One WindowApply per aggregate window expression. BWAG appends its window
columns after the input's, so expression i lands at input_field_count + i and
the input columns keep their indices — which is why the aggregate's own
argument expressions carry over unchanged despite being resolved against the
input schema. Non-aggregate window functions get no apply; they publish no
state to merge. This is the last of the stubs each earlier step stood on: with
`applies` empty the operator was a passthrough, which is why the state was
provably correct and the output was still partition-local.

SUM goes through the Aggregate path even though the cheaper Scalar path covers
it. Seeding an accumulator and replaying rows is the shape non-decomposable
aggregates need, and exercising it where the answer is independently checkable
beats the arrow-kernel shortcut. Choosing Scalar where it applies is a later
optimization, worth measuring.

Also flattens the codec arms added in the previous commit. The prefix-state
encoder was three nested maps around a transpose; it is now six named helpers
built from plain loops, taking the decode arm from ~85 lines to 13 and the
encode arm from ~95 to 24. Option handling is an explicit match rather than
`.map(..).transpose()?`, which puts the absent-versus-empty distinction where
a reader can see it, and each helper names what it was converting so failures
read as "failed to encode prefix state" rather than an anonymous try_from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@avantgardnerio
avantgardnerio force-pushed the brent/prefix-merge-scaffold branch from 8696f35 to f85b988 Compare August 14, 2026 16:56
Five places still described a half-built system. Each was accurate when
written and became a lie at a different commit:

- the client e2e's "Fails today" doc, now stating the quiet failure it
  guards against rather than predicting one
- the rule's "Status: shape only", which claimed the rewrite corrected
  nothing; it now records what it is correct for, and that h2o Q7 is blocked
  on the sketch's Float64 restriction rather than on this rule
- prefix_merge's "Nothing in-tree collects it yet", which now points at the
  collector that does
- the collector's and the stage's scaffolding-log comments, both explaining
  themselves as stand-ins for consumers that now exist

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@avantgardnerio avantgardnerio changed the title feat(core): scaffold PrefixMergeExec for cross-partition windowed-aggregate state merge feat(core): PrefixMergeExec for cross-partition windowed-aggregate state merge Aug 14, 2026
Comment on lines +782 to +787
for i in 0..num_rows {
let row_args: Vec<ArrayRef> =
arg_arrays.iter().map(|a| a.slice(i, 1)).collect();
self.accumulator.update_batch(&row_args)?;
new_values.push(self.accumulator.evaluate()?);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it re derives work the upstream BWAG already did

Yes, and that is exactly what the second pass is doing: the upstream window already advanced an accumulator through every row, and this walks it again from the carry-in.

The reason it is not avoidable today is where the state is exposed. apache/datafusion#24035 publishes accumulator state at PARTITION BY group close, one state per group, so there is no per row state to carry into a batch at a time correction.

there may be a middle path where the upstream emits partial state columns

I think that is worth pursuing, but it needs more than the API. DataFusion's dense HLL is 16 KiB per sketch (approx_distinct.rs#L257), so materializing per row state for a sketch is 16 KiB times row count as a column. A workable version probably needs a cheaper per row representation than raw accumulator state, which makes it a real piece of design rather than a switch to flip.

Worth being precise about the current state: this PR does not select the scalar path yet. WindowApply::Scalar exists on the operator and applies batch at a time via arrow kernels, but the rule currently builds an Aggregate apply for every window expression, including SUM. Selecting Scalar where it applies will land on this PR before merge, and for SUM, COUNT, MIN and MAX it removes this cost rather than halving it.

Could you run it over a realistic partition

Yes. A three row test does not surface any of this. The trade we are making is 2x local work divided across cores (theoretically 16x on 32, to be measured shortly). Benchmarks will land in this thread before anything else is built on the shape.

Comment on lines +733 to +741
// TODO: watch the size of this. Task completion is a hot, frequent
// message, and sketch-backed aggregates make the payload unbounded in a
// way row counts and quantile sketches are not — an HLL or KLL state is
// kilobytes per window expression per partition, and a task covering a
// wide partition slice carries one of each. If it stops being small,
// write the state as a sidecar next to the shuffle files instead, the way
// sort-shuffle already writes `<data>.arrow.index` beside its data
// (`sort_shuffle::get_index_path`), and send only a reference here. That
// keeps the completion message fixed-size regardless of aggregate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HLL, KLL or TDigest state per task per window expression is not small

Agreed, and it is worth sizing. DataFusion's dense HLL is 16 KiB per sketch (approx_distinct.rs#L257). A task carries one state per (partition in its slice, aggregate window expression), so max_partitions_per_task multiplies it: a 32 partition slice with a single sketch aggregate puts 512 KiB on that task's completion message.

Two things make me think the current shape is defensible as a starting point rather than a commitment. RuntimeStatsExec already ships quantile sketches over this exact path, so this adds a second payload of a class task status already carries rather than introducing one. And the escape hatch is cheap: the state can be written as a sidecar beside the shuffle files, the way sort shuffle already writes <data>.arrow.index next to its data, with only a reference on the message. That keeps completion fixed size regardless of aggregate, and it is a change to this one field rather than to the design around it.

What I have not done is measure it. The e2e only exercises a Float64 SUM, where the payload is a handful of bytes. If you would rather see a real number for approx_distinct before this merges, I can add a test that reports the encoded size and post it here.

Comment on lines +92 to 103
if !under_collect && let Some(sliceable) = as_partition_sliceable(&plan) {
let children = plan.children();
let [child] = children.as_slice() else {
return internal_err!(
"RangeFilterExec must have exactly 1 child, got {}",
"{} is PartitionSliceable but has {} children, expected 1",
plan.name(),
children.len()
);
};
let new_child = restrict((*child).clone(), partitions, false)?;
let raw_bounds = rf.raw_bounds().ok_or_else(|| {
datafusion::common::DataFusionError::Internal(
"RangeFilterExec: task-restriction before resolve_bounds()".into(),
)
})?;
let sliced_bounds: Vec<_> = partitions
.iter()
.map(|&global| {
raw_bounds.get(global).cloned().ok_or_else(|| {
datafusion::common::DataFusionError::Internal(format!(
"RangeFilterExec: partition index {global} out of bounds ({} raw bounds)",
raw_bounds.len()
))
})
})
.collect::<datafusion::common::Result<_>>()?;
return Ok(Arc::new(RangeFilterExec::try_new_resolved(
new_child,
rf.routing_expr().clone(),
rf.halo_lo().clone(),
rf.halo_hi().clone(),
sliced_bounds,
)?));
return sliceable.slice_to_partitions(new_child, partitions);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any rule that repartitions the input to the same partition count would silently attach each partition's offsets to the wrong rows

This was real and it fired on the first end to end run, though not by the route you predicted. Task specialization already restricts a stage plan to one task's partition slice, so PrefixMergeExec was rebuilt over a narrower input while still holding state keyed by global partition index. It failed loudly only because try_new validates the length: per_partition_state.len() 4 does not match input partition count 1. Without that check it is exactly the silent misattachment you describe, since a task's execute(k) numbers its own slice from zero.

Fixed by slicing state and Scalar.offset parallel to the input restriction, following the precedent RangeFilterExec already set for its bounds. It is behind a PartitionSliceable trait so each operator implements the slicing next to the fields being sliced, rather than the task builder knowing their internals: add a partition indexed field and the code that has to slice it is in the same file.

The e2e runs with max_partitions_per_task = 2, so tasks carry a genuine multi partition slice rather than the degenerate one partition case.

Comment on lines +894 to +906
PhysicalPlanType::PrefixMerge(node) => {
let [input] = inputs else {
return Err(DataFusionError::Internal(format!(
"PrefixMergeExec expects exactly 1 input, got {}",
inputs.len()
)));
};
let schema = input.schema();
Ok(Arc::new(PrefixMergeExec::try_new_resolved(
input.clone(),
decode_window_applies(&node.applies, ctx, schema.as_ref(), self)?,
decode_prefix_state(&node.per_partition_state)?,
)?))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arc<AggregateUDF>, Vec<Arc<dyn PhysicalExpr>>, and Vec<ScalarValue> sketch state all have to cross the wire

Done on this PR. The UDAF crosses by name and is resolved from the executor's FunctionRegistry on decode, args as PhysicalExprNode, and state as ScalarValue so sketch state works unchanged. A round trip test covers both WindowApply shapes, the registry lookup, and the distinction between a slot with no state and a slot with empty state.

Encoding refuses while the prefix state is unresolved, matching RangeFilterExec's refusal on unresolved bounds. An executor has no route to that state, so a plan reaching the wire without it could only emit partition-local aggregates.

…ype drift

Three small ones from review.

ScalarOp::Overwrite claimed to fit last_value. Over an ever-expanding frame
last_value is the current row's own value and needs no correction, so
overwriting every row with one scalar would be wrong. The doc now says
first_value only, and says why last_value is excluded.

ScalarOp and WindowApply are #[non_exhaustive]. Both are expected to grow —
the ranking family needs a segment-tree broadcast shape — and each addition
would otherwise be a breaking change for anyone matching on them. Construction
is unaffected, so the rule still builds an Aggregate apply.

Type drift now names the apply responsible. Arrow reports a mismatch at a
column index and nothing about which correction produced it, which is the
wrong half when several applies rewrite one batch. rebuild_batch reports
"applies[3] produced Int64 for column 1, which the schema declares as
Float64", using the apply_index AggregateApply already carried for exactly
this and did not use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@avantgardnerio
avantgardnerio force-pushed the brent/prefix-merge-scaffold branch from fe70013 to 7ae3ca8 Compare August 14, 2026 17:54
Comment on lines +177 to +184
/// `output := offset`. Ignores `row_value`. Fits `first_value` over an
/// ever-expanding frame: every row's answer is the same global first
/// value, so the scheduler picks it once and every row gets a copy.
///
/// Not `last_value`. Over an ever-expanding frame that is the current
/// row's own value and needs no correction at all, so overwriting every
/// row with one scalar would be wrong.
Overwrite,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which frame is that aimed at?

None, you are right. Over an ever-expanding frame last_value is the current row's own value and needs no correction, so overwriting every row with a single scalar would be wrong. The doc now claims first_value only and says why last_value is excluded, so the next reader does not have to re-derive it.

Comment on lines +194 to +198
/// `#[non_exhaustive]`: the two shapes here cover what the prefix rewrite
/// plants today, and a third (a segment-tree broadcast for the ranking
/// family) is anticipated.
#[derive(Debug, Clone)]
#[non_exhaustive]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Marking both #[non_exhaustive] now costs nothing and saves a breaking change on the first new variant

Done, on both. Construction is unaffected so the rewrite rule still builds an Aggregate apply directly; it is exhaustive matching that now needs a wildcard, which is the part that would have broken.

Comment on lines +878 to +884
fn rebuild_batch(
schema: SchemaRef,
columns: Vec<ArrayRef>,
apply_index: usize,
output_column: usize,
) -> Result<RecordBatch> {
let replaced = columns[output_column].data_type().clone();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RecordBatch::try_new fails with an opaque arrow error rather than something naming the offending applies[i]

Fixed. Both apply paths now rebuild through this helper, which reports applies[3] produced Int64 for column 1, which the schema declares as Float64. AggregateApply already carried apply_index for exactly this and was not using it at the failure site. There is a test on the message, since it is otherwise an untested branch.

@avantgardnerio avantgardnerio changed the title feat(core): PrefixMergeExec for cross-partition windowed-aggregate state merge feat: parallel prefix scan for UNBOUNDED PRECEDING window aggregates Aug 14, 2026
@avantgardnerio

Copy link
Copy Markdown
Contributor Author

Heads up that this is functional now rather than a scaffold. It runs end to end and ballista/client/tests/prefix_window.rs asserts a distributed running sum against the serial answer through a real cluster. I have replied to each of your points inline on the relevant lines so they can be resolved individually.

Leaving it in draft: benchmarks for the per-row replay are not in yet, and the scalar path selection lands before merge. Both are tracked in the threads.

BaselineMetrics for elapsed_compute and output_rows, per partition because
execute(partition) builds a stream each. Plus a rows_corrected counter and a
separate timer per apply path.

The split is the point. WindowApply::Scalar is an arrow kernel over a whole
batch; WindowApply::Aggregate seeds an accumulator and replays every row
through it. A sketch-heavy query pays the second and a SUM-heavy one need not,
which a single total would hide. On the client e2e the operator now reports
aggregate_apply_time=234.34us against elapsed_compute=238.60us, so the replay
is 98% of its time on a trivial SUM — a shape rather than a magnitude at 16
rows, but it makes the replay cost something the operator reports in
production rather than something only a benchmark can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +859 to +865
struct ApplyStream {
input: SendableRecordBatchStream,
appliers: Vec<PreparedApply>,
schema: SchemaRef,
baseline: BaselineMetrics,
path_metrics: PathMetrics,
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PrefixMergeExec doesn't implement metrics() and ApplyStream has no BaselineMetrics

Added. BaselineMetrics for elapsed_compute and output_rows, per partition since execute(partition) builds a stream each, plus a rows_corrected counter.

The two apply paths are timed separately rather than together. WindowApply::Scalar is an arrow kernel over a whole batch and WindowApply::Aggregate seeds an accumulator and replays every row, so a single number would cover two quite different costs for a query that mixes them. It also makes the replay you flagged something the operator reports in production rather than something only a benchmark can see.

I will post these numbers along with the benchmarking results.

Vec<Option<Vec<ScalarValue>>> appeared in this operator's public signatures,
where it is neither readable nor searchable, and it spelled "no state for this
window expression" two ways: a missing index, and a None at a present index.
Every caller handled both. slot(window_expr_index) collapses them into one
answer, and a later change to the representation now stays internal.

Also removes two comments claiming DataFusion guarantees at most one PARTITION
BY group per partition. It does not — apache/datafusion#24035 shipped a
callback keyed by group, so that invariant is ours, and the scheduler enforces
it by rejecting any report carrying a key. A window that does have a PARTITION
BY needs nothing from this operator anyway: BoundedWindowAggExec asks for
KeyPartitioned input, so each partition's window is already independent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +163 to +166
pub struct FinalizedPartitionState {
/// Indexed by position in the upstream operator's `window_expr()` list.
per_window_expr: Vec<Option<Vec<ScalarValue>>>,
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A newtype now would keep that swap internal

Done, though not for the reason you gave, and the difference seems worth recording.

apache/datafusion#24007 did not land. What shipped in DataFusion 55 is apache/datafusion#24035, a WindowStateObserver callback rather than a getter, and it publishes no type equivalent to this one. So there is no upstream type to swap to and the break you were guarding against cannot happen.

The newtype earns its place anyway. Vec<Option<Vec<ScalarValue>>> appears in this operator's public signatures, where it is neither readable nor searchable, and it spells "no state for this window expression" two ways: a missing index, and a None at a present index. Every caller had to handle both. slot(window_expr_index) now collapses them into one answer, and a later change to the representation stays internal.

While looking at this I removed two comments claiming the DataFusion side guarantees at most one PARTITION BY group per partition. It does not, and never did — the callback is keyed by group, so that invariant is ours. The scheduler now enforces it by rejecting any report that carries a key rather than flattening two groups together. Worth noting a window that does have a PARTITION BY needs nothing from this operator: BoundedWindowAggExec asks for KeyPartitioned input, so each partition's window is already independent and there is no serial bottleneck to remove.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants